import { NextResponse } from 'next/server' import type { EntityManager } from '@mikro-orm/postgresql' import { createRequestContainer } from '@open-mercato/shared/lib/di/container' import type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus' import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands' import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors' import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' import type { PaymentGatewayClientSession } from '@open-mercato/shared/modules/payment_gateways/types' import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service' import { GatewayTransaction } from '@open-mercato/core/modules/payment_gateways/data/entities' import { CheckoutLink, CheckoutTransaction } from '../../../../data/entities' import { publicSubmitSchema } from '../../../../data/validators' import { handleCheckoutRouteError, requireCheckoutPasswordSession } from '../../../helpers' import { emitCheckoutEvent } from '../../../../events' import { buildConsentProof, isCheckoutLinkPublic, mapGatewayStatusToCheckoutStatus, resolveSubmittedAmount, validateDescriptorCurrencies, } from '../../../../lib/utils' import { validateCheckoutCustomerData } from '../../../../lib/customerDataValidation' import { checkoutSubmitRateLimitConfig, enforceCheckoutRateLimit } from '../../../../lib/rateLimiter' import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers' import { checkoutTag } from '../../../openapi' import { createLogger } from '@open-mercato/shared/lib/logger' const logger = createLogger('checkout') type CachedSubmitResponse = { transactionId: string redirectUrl?: string | null paymentSession?: (PaymentGatewayClientSession & { providerKey: string | null gatewayTransactionId: string }) | null } type CheckoutLegalDocumentRequirement = { required?: boolean } type CheckoutCustomerFieldRequirement = { key: string required?: boolean } function normalizePort(url: URL): string { if (url.port) return url.port return url.protocol === 'https:' ? '443' : '80' } function normalizeConfiguredOrigin(candidate: string): string | null { try { return new URL(candidate).origin } catch { return null } } function isLoopbackHostname(hostname: string): boolean { return hostname === '127.0.0.1' || hostname === 'localhost' } function splitHeaderValues(value: string | null): string[] { if (!value) return [] return value .split(',') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) } function isAllowedRequestOrigin(submittedOrigin: string, allowedOrigin: string): boolean { if (submittedOrigin === allowedOrigin) return true try { const submittedUrl = new URL(submittedOrigin) const allowedUrl = new URL(allowedOrigin) return submittedUrl.protocol === allowedUrl.protocol && normalizePort(submittedUrl) === normalizePort(allowedUrl) && isLoopbackHostname(submittedUrl.hostname) && isLoopbackHostname(allowedUrl.hostname) } catch { return false } } const CONFIGURED_ALLOWED_ORIGINS: string[] = (process.env.CHECKOUT_ALLOWED_ORIGINS ?? '') .split(',') .map((origin) => origin.trim()) .filter((origin) => origin.length > 0) function addAllowedOrigin(allowedOrigins: Set, candidate: string) { const normalized = normalizeConfiguredOrigin(candidate) if (!normalized) return allowedOrigins.add(normalized) try { const parsed = new URL(normalized) if (!isLoopbackHostname(parsed.hostname)) return for (const hostname of ['127.0.0.1', 'localhost']) { for (const protocol of ['http:', 'https:']) { const variant = new URL(normalized) variant.hostname = hostname variant.protocol = protocol allowedOrigins.add(variant.origin) } } } catch { // Ignore invalid variants. } } function collectConfiguredOrigins(): string[] { const origins = [...CONFIGURED_ALLOWED_ORIGINS] for (const origin of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) { if (origin) origins.push(origin) } return origins } // Allowed origins are built ONLY from server-configured values. The inbound // request URL, Host and X-Forwarded-Host headers are attacker-controllable on // deployments that do not normalize them at the edge, so they must never seed // the allowlist (otherwise a spoofed Host self-passes the origin check). function buildAllowedOrigins(): string[] { const allowedOrigins = new Set() for (const origin of collectConfiguredOrigins()) { addAllowedOrigin(allowedOrigins, origin) } return Array.from(allowedOrigins) } // Resolve the origin used to build gateway-bound success/cancel/return URLs. // Always prefer a server-pinned configured origin; only fall back to the request // origin on loopback (local dev/test), where the Host cannot be spoofed by a // remote attacker. A non-loopback request with no configured origin is a // misconfiguration and is rejected rather than emitting an attacker-controllable URL. function resolveServerOrigin(req: Request): string { for (const candidate of collectConfiguredOrigins()) { const normalized = normalizeConfiguredOrigin(candidate) if (normalized) return normalized } const requestUrl = new URL(req.url) if (isLoopbackHostname(requestUrl.hostname)) return requestUrl.origin throw new CrudHttpError(500, { error: 'Checkout origin is not configured' }) } // Normalize a host value (bare host, host:port, or full origin) to its authority // (`hostname` plus any non-default port), lower-cased. Protocol is intentionally // dropped: the gateway-bound URLs are already pinned to the configured origin, so // the host guard below only needs to reject foreign hosts — not enforce protocol // or internal-upstream-host parity, which would 403 legitimate proxied requests. function hostAuthority(value: string): string | null { const candidate = value.includes('://') ? value : `https://${value}` try { const url = new URL(candidate) if (!url.hostname) return null const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : '' return `${url.hostname.toLowerCase()}${port}` } catch { return null } } function allowedHostAuthorities(allowedOrigins: string[]): Set { const authorities = new Set() for (const origin of allowedOrigins) { const authority = hostAuthority(origin) if (authority) authorities.add(authority) } return authorities } // The externally-asserted host: the proxy-forwarded X-Forwarded-Host when present, // otherwise the Host header. The internal upstream Host (and protocol) are ignored // so a correctly-proxied request is not rejected for not matching the public origin. function requestHostAuthorities(req: Request): string[] { const forwarded = splitHeaderValues(req.headers.get('x-forwarded-host')) const hostValues = forwarded.length > 0 ? forwarded : splitHeaderValues(req.headers.get('host')) const authorities: string[] = [] for (const value of hostValues) { const authority = hostAuthority(value) if (authority) authorities.push(authority) } return authorities } function isIdempotencyConflict(error: unknown): boolean { const message = error instanceof Error ? error.message : '' return message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_index') || message.includes('checkout_transactions_organization_id_tenant_id_link_idempotency_key_unique') || message.includes('duplicate key value') } function readClientSession( metadata: Record | null | undefined, ): PaymentGatewayClientSession | null { const candidate = metadata?.clientSession if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null const type = (candidate as { type?: unknown }).type if (type === 'redirect') { const redirectUrl = (candidate as { redirectUrl?: unknown }).redirectUrl if (typeof redirectUrl !== 'string' || redirectUrl.trim().length === 0) return null const target = (candidate as { target?: unknown }).target return { type: 'redirect', redirectUrl, target: target === 'top' ? 'top' : 'self', } } if (type !== 'embedded') return null if (typeof (candidate as { rendererKey?: unknown }).rendererKey !== 'string') return null const payload = (candidate as { payload?: unknown }).payload const settings = (candidate as { settings?: unknown }).settings return { type: 'embedded', rendererKey: (candidate as { rendererKey: string }).rendererKey, payload: payload && typeof payload === 'object' && !Array.isArray(payload) ? payload as Record : undefined, settings: settings && typeof settings === 'object' && !Array.isArray(settings) ? settings as Record : undefined, } } async function buildSubmitResponse( req: Request, em: EntityManager, link: CheckoutLink, transaction: CheckoutTransaction, providerKey: string | null | undefined, ): Promise { const gatewayTransaction = transaction.gatewayTransactionId ? await em.findOne(GatewayTransaction, { id: transaction.gatewayTransactionId, organizationId: transaction.organizationId, tenantId: transaction.tenantId, deletedAt: null, }) : null const serverOrigin = resolveServerOrigin(req) const clientSession = gatewayTransaction ? readClientSession(gatewayTransaction.gatewayMetadata) : null const paymentSession = (clientSession && gatewayTransaction) ? { ...clientSession, ...(clientSession.type === 'embedded' ? { payload: { ...(clientSession.payload ?? {}), returnUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transaction.id)}`, cancelUrl: `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transaction.id)}`, }, } : {}), providerKey: providerKey ?? gatewayTransaction.providerKey ?? null, gatewayTransactionId: gatewayTransaction.id, } : null return { transactionId: transaction.id, redirectUrl: gatewayTransaction?.redirectUrl ?? null, paymentSession, } } export const metadata = { path: '/checkout/pay/[slug]/submit', POST: { requireAuth: false }, } export async function POST(req: Request, { params }: { params: Promise<{ slug: string }> | { slug: string } }) { try { const container = await createRequestContainer() // Fail-closed: this endpoint creates payment sessions that can charge cards. const rateLimitResponse = await enforceCheckoutRateLimit({ req, container, config: checkoutSubmitRateLimitConfig, namespace: 'checkout-submit', errorMessage: 'Too many payment attempts. Please try again later.', posture: 'fail-closed', }) if (rateLimitResponse) return rateLimitResponse const resolvedParams = await params const allowedOrigins = buildAllowedOrigins() if (allowedOrigins.length > 0) { // Reject spoofed Host / X-Forwarded-Host before any business logic runs so // an attacker-controlled host can never flow into gateway-bound URLs. Hosts // are matched by authority (protocol-independent) so a correctly TLS-proxied // request — internal http, X-Forwarded-Proto https — is not rejected. const allowedAuthorities = allowedHostAuthorities(allowedOrigins) const hostAuthorities = requestHostAuthorities(req) if ( hostAuthorities.length > 0 && !hostAuthorities.every((authority) => allowedAuthorities.has(authority)) ) { return NextResponse.json({ error: 'Invalid request host' }, { status: 403 }) } const origin = req.headers.get('origin') const referer = req.headers.get('referer') if (origin || referer) { const submittedOrigin = origin ?? (referer ? new URL(referer).origin : null) if (submittedOrigin && !allowedOrigins.some((allowedOrigin) => isAllowedRequestOrigin(submittedOrigin, allowedOrigin))) { return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }) } } } const idempotencyKey = req.headers.get('Idempotency-Key')?.trim() if (!idempotencyKey) { return NextResponse.json({ error: 'Idempotency-Key header is required' }, { status: 400 }) } if (idempotencyKey.length < 16 || idempotencyKey.length > 128) { return NextResponse.json({ error: 'Idempotency-Key must be between 16 and 128 characters' }, { status: 400 }) } const body = publicSubmitSchema.parse(await req.json().catch(() => ({}))) const em = container.resolve('em') const commandBus = container.resolve('commandBus') as CommandBus const paymentGatewayService = container.resolve('paymentGatewayService') as PaymentGatewayService const link = await findOneWithDecryption(em, CheckoutLink, { slug: resolvedParams.slug, deletedAt: null, }) if (!link) { return NextResponse.json({ error: 'Payment link not found' }, { status: 404 }) } if (!isCheckoutLinkPublic(link.status)) { throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' }) } if (link.passwordHash) { requireCheckoutPasswordSession(req, link.slug, { linkId: link.id, sessionVersion: link.passwordHash, }) } if (!link.gatewayProviderKey) { throw new CrudHttpError(422, { error: 'A payment gateway must be configured before this link can be used' }) } const legalDocuments = link.legalDocuments && typeof link.legalDocuments === 'object' ? link.legalDocuments as Partial> : {} const legalConsentErrors: Record = {} for (const key of ['terms', 'privacyPolicy'] as const) { const document = legalDocuments[key] if (document?.required === true && body.acceptedLegalConsents?.[key] !== true) { legalConsentErrors[`acceptedLegalConsents.${key}`] = 'checkout.payPage.validation.documentRequired' } } if (Object.keys(legalConsentErrors).length > 0) { throw new CrudHttpError(422, { error: 'checkout.payPage.validation.fixErrors', fieldErrors: legalConsentErrors, }) } const collectedCustomerData = link.collectCustomerDetails === false ? {} : body.customerData const customerFields = Array.isArray(link.customerFieldsSchema) ? link.customerFieldsSchema as CheckoutCustomerFieldRequirement[] : [] if (link.collectCustomerDetails !== false) { const customerFieldErrors = validateCheckoutCustomerData(customerFields, collectedCustomerData) if (Object.keys(customerFieldErrors).length > 0) { throw new CrudHttpError(422, { error: 'checkout.payPage.validation.fixErrors', fieldErrors: customerFieldErrors, }) } } const resolvedAmount = resolveSubmittedAmount(link, body) const existingTransaction = await findOneWithDecryption(em, CheckoutTransaction, { linkId: link.id, idempotencyKey, organizationId: link.organizationId, tenantId: link.tenantId, }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId }) validateDescriptorCurrencies(link.gatewayProviderKey, [ existingTransaction?.currencyCode ?? resolvedAmount.currencyCode, ]) if (existingTransaction?.gatewayTransactionId) { return NextResponse.json( await buildSubmitResponse(req, em, link, existingTransaction, link.gatewayProviderKey), ) } const transactionInput = { linkId: link.id, amount: resolvedAmount.amount, currencyCode: resolvedAmount.currencyCode, idempotencyKey, customerData: collectedCustomerData, firstName: typeof collectedCustomerData.firstName === 'string' ? collectedCustomerData.firstName : null, lastName: typeof collectedCustomerData.lastName === 'string' ? collectedCustomerData.lastName : null, email: typeof collectedCustomerData.email === 'string' ? collectedCustomerData.email : null, phone: typeof collectedCustomerData.phone === 'string' ? collectedCustomerData.phone : null, selectedPriceItemId: resolvedAmount.selectedPriceItemId, acceptedLegalConsents: buildConsentProof(link, body.acceptedLegalConsents), ipAddress: req.headers.get('x-forwarded-for') ?? null, userAgent: req.headers.get('user-agent') ?? null, tenantId: link.tenantId, organizationId: link.organizationId, } const ctx: CommandRuntimeContext = { container, auth: null, organizationScope: null, selectedOrganizationId: link.organizationId, organizationIds: [link.organizationId], request: req, } let transactionId = existingTransaction?.id ?? null if (!transactionId) { try { const created = await commandBus.execute('checkout.transaction.create', { input: transactionInput, ctx, }) transactionId = created.result.id } catch (error) { if (!isIdempotencyConflict(error)) { throw error } const duplicated = await findOneWithDecryption(em, CheckoutTransaction, { linkId: link.id, idempotencyKey, organizationId: link.organizationId, tenantId: link.tenantId, }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId }) if (!duplicated) { throw error } transactionId = duplicated.id } } if (!transactionId) { throw new CrudHttpError(500, { error: 'Failed to initialize checkout transaction' }) } const serverOrigin = resolveServerOrigin(req) const successUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/success/${encodeURIComponent(transactionId)}` const cancelUrl = `${serverOrigin}/pay/${encodeURIComponent(link.slug)}/cancel/${encodeURIComponent(transactionId)}` const transaction = await findOneWithDecryption(em, CheckoutTransaction, { id: transactionId, organizationId: link.organizationId, tenantId: link.tenantId, }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId }) if (!transaction) { throw new CrudHttpError(404, { error: 'Transaction not found' }) } const sessionAmount = Number(transaction.amount) if (!Number.isFinite(sessionAmount)) { throw new CrudHttpError(500, { error: 'Invalid checkout transaction amount' }) } const sessionCurrencyCode = transaction.currencyCode if (!transaction.gatewayTransactionId) { const configuredPaymentTypes = Array.isArray(link.gatewaySettings?.paymentTypes) ? link.gatewaySettings.paymentTypes.filter( (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0, ) : [] const rendererKey = typeof link.gatewaySettings?.rendererKey === 'string' && link.gatewaySettings.rendererKey.trim().length > 0 ? link.gatewaySettings.rendererKey.trim() : undefined const rendererSettings = link.gatewaySettings?.rendererSettings && typeof link.gatewaySettings.rendererSettings === 'object' && !Array.isArray(link.gatewaySettings.rendererSettings) ? link.gatewaySettings.rendererSettings as Record : undefined const presentationMode = link.gatewaySettings?.presentationMode === 'embedded' || link.gatewaySettings?.presentationMode === 'redirect' || link.gatewaySettings?.presentationMode === 'auto' ? link.gatewaySettings.presentationMode : undefined let sessionResult: Awaited> try { sessionResult = await paymentGatewayService.createPaymentSession({ providerKey: link.gatewayProviderKey, paymentId: transactionId, idempotencyKey, amount: sessionAmount, currencyCode: sessionCurrencyCode, paymentTypes: configuredPaymentTypes.length > 0 ? configuredPaymentTypes : undefined, description: link.title ?? link.name, successUrl, cancelUrl, metadata: { checkoutLinkId: link.id, checkoutSlug: link.slug, }, presentation: rendererKey || rendererSettings || presentationMode ? { ...(presentationMode ? { mode: presentationMode } : {}), ...(rendererKey ? { rendererKey } : {}), ...(rendererSettings ? { rendererSettings } : {}), } : undefined, organizationId: link.organizationId, tenantId: link.tenantId, }) } catch (error) { // The gateway itself failed — mark the transaction failed and surface 502. await commandBus.execute('checkout.transaction.updateStatus', { input: { id: transaction.id, status: 'failed', paymentStatus: transaction.paymentStatus ?? 'failed', organizationId: link.organizationId, tenantId: link.tenantId, }, ctx, }).catch(() => undefined) logger.error('Failed to create payment session', { linkId: link.id, transactionId: transaction.id, providerKey: link.gatewayProviderKey, err: error, }) throw new CrudHttpError(502, { error: 'checkout.payPage.errors.sessionStart' }) } // Update the transaction status to reflect the gateway response. // If the webhook already raced ahead and completed this transaction, // updateStatus will throw a 409 conflict — that is correct behavior and // means the payment succeeded, so we treat it as benign and fall through // to return the real, already-terminal transaction below. let statusConflict = false try { await commandBus.execute('checkout.transaction.updateStatus', { input: { id: transaction.id, status: mapGatewayStatusToCheckoutStatus(sessionResult.transaction.unifiedStatus), paymentStatus: sessionResult.transaction.unifiedStatus, gatewayTransactionId: sessionResult.transaction.id, organizationId: link.organizationId, tenantId: link.tenantId, }, ctx, }) } catch (error) { const isConflict = isCrudHttpError(error) && error.status === 409 && ( (error.body as Record | undefined)?.code === 'concurrent_status_update' || (error.body as Record | undefined)?.code === 'invalid_status_transition' ) if (!isConflict) throw error logger.info('Transaction status already advanced by concurrent writer — webhook won the race', { linkId: link.id, transactionId: transaction.id, conflictCode: (error.body as Record | undefined)?.code, }) statusConflict = true } const refreshedTransaction = await findOneWithDecryption(em, CheckoutTransaction, { id: transaction.id, organizationId: link.organizationId, tenantId: link.tenantId, }, undefined, { organizationId: link.organizationId, tenantId: link.tenantId }) if (!refreshedTransaction) { throw new CrudHttpError(404, { error: 'Transaction not found' }) } if (!statusConflict) { await emitCheckoutEvent('checkout.transaction.sessionStarted', { transactionId: refreshedTransaction.id, linkId: refreshedTransaction.linkId, templateId: link.templateId ?? null, slug: link.slug, status: refreshedTransaction.status, paymentStatus: refreshedTransaction.paymentStatus ?? null, amount: Number(refreshedTransaction.amount), currency: refreshedTransaction.currencyCode, gatewayProvider: link.gatewayProviderKey, gatewayTransactionId: refreshedTransaction.gatewayTransactionId ?? null, occurredAt: new Date().toISOString(), tenantId: link.tenantId, organizationId: link.organizationId, }).catch(() => undefined) } return NextResponse.json( await buildSubmitResponse(req, em, link, refreshedTransaction, link.gatewayProviderKey), { status: 201 }, ) } return NextResponse.json(await buildSubmitResponse(req, em, link, transaction, link.gatewayProviderKey), { status: 201 }) } catch (error) { return handleCheckoutRouteError(error) } } export const openApi = { tags: [checkoutTag], methods: { POST: { errors: [ { status: 429, description: 'Too many payment attempts', schema: rateLimitErrorSchema }, { status: 503, description: 'Rate limiting could not be enforced, so the payment was rejected', schema: rateLimitErrorSchema }, ], }, }, } export default POST